﻿/*                              
   Copyright 2011 Nils Kopal, Christopher Krüger, Simone Sauer, Dennis Nolte, Uni Duisburg-Essen

   Licensed under the Apache License, Version 2.0 (the "License");
   you may not use this file except in compliance with the License.
   You may obtain a copy of the License at

       http://www.apache.org/licenses/LICENSE-2.0

   Unless required by applicable law or agreed to in writing, software
   distributed under the License is distributed on an "AS IS" BASIS,
   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
   See the License for the specific language governing permissions and
   limitations under the License.
*/
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Net;
using System.Net.Sockets;
using System.Threading;
using System.IO;
using Sharebert.LinkLayerLibrary.API;
using System.Threading.Tasks;
using log4net;

namespace Sharebert.LinkLayerLibrary
{
    /// <summary>
    /// Manages the connection to other peers. A connection instance is able to open a connection to another computer
    /// or can listen for new connections.
    /// This class represents the link layer part of the Sharebert p2p network and also performs the work of the underlying layers.
    /// </summary>
    public class Connection : IConnection
    {

	private static readonly ILog logger = LogManager.GetLogger(typeof(Connection));
		
        private const int DEFAULT_TIMEOUT_READ = 30;

        private const int DEFAULT_TIMEOUT_WRITE = 10;


        /// <summary>
        /// Initializes a new instance of the Connection class. This will be used to open a connection to a 
        /// remote host or to listen for new connections.
        /// </summary>
        public Connection()
        {
            Initialize(null, DEFAULT_TIMEOUT_READ, DEFAULT_TIMEOUT_WRITE);
            this.RemoteAddress = null;
            this.LocalAddress = null;
            this.RemotePort = 0;
            this.LocalPort = 0;
        }

        /// <summary>
        /// Private constructor which initializes a new Connection instance. Used internally to setup
        /// incoming connections.
        /// </summary>
        /// <param name="socket">The network socket</param>
        private Connection(Socket socket, int readTimeout, int writeTimeout)
        {
            Initialize(socket, readTimeout, writeTimeout);
            this.LocalAddress = ((IPEndPoint)this.socket.LocalEndPoint).Address.ToString();
            this.LocalPort = ((IPEndPoint)this.socket.LocalEndPoint).Port;
            this.RemoteAddress = ((IPEndPoint)this.socket.RemoteEndPoint).Address.ToString();
            this.RemotePort = ((IPEndPoint)this.socket.RemoteEndPoint).Port;

            NetworkStream nstream = new NetworkStream(socket);
            nstream.ReadTimeout = readTimeout * 1000;
            nstream.WriteTimeout = writeTimeout * 1000;
            this.binWriter = new BinaryWriter(nstream, Encoding.ASCII);
            this.binReader = new BinaryReader(nstream, Encoding.ASCII);
        }

        private void Initialize(Socket socket, int readTimeout, int writeTimeout)
        {
            this.socket = socket;
            this.ReadTimeout = readTimeout;
            this.WriteTimeout = writeTimeout;
            this.closed = false;
        }

        /// <summary>
        /// Opens a connection to the specified host.
        /// </summary>
        /// <param name="remoteAddress">IP address or hostname</param>
        /// <param name="remotePort">The port</param>
        /// <exception cref="System.ArgumentException">remoteAddress is empty</exception>
        /// <exception cref="System.ArgumentNullException">remoteAddress is null</exception>
        /// <exception cref="System.ArgumentOutOfRangeException">invalid port</exception>
        /// <exception cref="NetworkException">Could not connect or already connected</exception>
        public void Open(string remoteAddress, int remotePort)
        {
            if (this.Connected)
            {
                throw new NetworkException("Already connected!");
            }
	    IPAddress ipAddress = null;
	    bool isIpAddress = IPAddress.TryParse(remoteAddress, out ipAddress);
	    if(!isIpAddress) 
	    {
		var ips = Dns.GetHostAddresses(remoteAddress);
		if(ips.Length == 0) 
		{
			throw new NetworkException("Could not resolve ip address for host: " + remoteAddress);
		}
		ipAddress = ips[0];
	    }	
	    var ipEndPoint = new IPEndPoint(ipAddress, remotePort);

            try
            {
                // Create a new socket and connect to it
                Socket newSocket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
                newSocket.Connect(ipEndPoint);
                // newSocket.Connect(dnsEndPoint);

                // Open a network stream on this socket and configure it
                this.socket = newSocket;

                this.LocalAddress = ((IPEndPoint)this.socket.LocalEndPoint).Address.ToString();
                this.LocalPort = ((IPEndPoint)this.socket.LocalEndPoint).Port;
                this.RemoteAddress = ((IPEndPoint)this.socket.RemoteEndPoint).Address.ToString();
                this.RemotePort = ((IPEndPoint)this.socket.RemoteEndPoint).Port;

                NetworkStream nstream = new NetworkStream(socket);
                nstream.ReadTimeout = this.ReadTimeout * 1000;
                nstream.WriteTimeout = this.WriteTimeout * 1000;

                this.binWriter = new BinaryWriter(nstream, Encoding.ASCII);
                this.binReader = new BinaryReader(nstream, Encoding.ASCII);
                this.closed = false;

                // Start waiting for new messages
                Thread listenThread = new Thread(ListenMessageWorker);
                listenThread.IsBackground = true;
                listenThread.Start(this);
            }
            catch (Exception ex)
            {
                throw new NetworkException("Could not open connection!", ex);
            }
        }

        /// <summary>
        /// Closes the connection.
        /// </summary>
        public void Close()
        {
            lock (this.lockObj)
            {
                if (this.closed)
                {
                    return;
                }
                if (this.binWriter != null)
                {
                    this.binWriter.Close();
                }
                if (this.binReader != null)
                {
                    this.binReader.Close();
                }
                if (this.socket != null)
                {
                    try
                    {
                        this.socket.Shutdown(SocketShutdown.Both);
                    }
                    catch (Exception)
                    {
                    }
                    this.socket.Close();
                    this.closed = true;
                }
            }
        }


        #region Listening for new connections

        /// <summary>
        /// Listen on the specified address and port for new connections. An incoming connection will
        /// create a new thread to handle the new connection. This method is blocking.
        /// </summary>
        /// <param name="listenAddress">The IP address to listen on new connections</param>
        /// <param name="listenPort">The port to listen on</param>
        /// <param name="backlog">The backlog, which indicates the number of parallelly handled connections</param>
        /// <exception cref="ArgumentNullException">listenAddress is null</exception>
        /// <exception cref="ArgumentOutOfRangeException">backlog must be greater than zero</exception>
        /// <exception cref="NetworkException">Error while listening</exception>
        public void Listen(IPAddress listenAddress, int listenPort, uint backlog)
        {
            if (listenAddress == null)
            {
                throw new ArgumentNullException("listenAddress can not be null");
            }
            if (backlog == 0)
            {
                throw new ArgumentOutOfRangeException("The backlog must be greater than 0");
            }

            try
            {
                Socket listenSocket = new Socket(listenAddress.AddressFamily, SocketType.Stream, ProtocolType.Tcp);
                listenSocket.Bind(new IPEndPoint(listenAddress, listenPort));
                listenSocket.Listen((int)backlog);
                this.socket = listenSocket;
                this.LocalAddress = ((IPEndPoint)this.socket.LocalEndPoint).Address.ToString();
                this.LocalPort = ((IPEndPoint)this.socket.LocalEndPoint).Port;
                this.closed = false;

                while (true)
                {
                    Socket newSocket = this.socket.Accept();

                    // Initialize and configure new connection
                    Connection conn = new Connection(newSocket, this.ReadTimeout, this.WriteTimeout);

                    // Inform the above layer that a new connection has been established.
                    if (this.NewConnectionEstablished != null)
                    {
                        this.NewConnectionEstablished.Invoke(this, new ConnectionEventArgs(conn));
                    }
                    Task.Factory.StartNew(ListenMessageWorker, conn);
                }
            }
            catch (SocketException ex)
            {
                if (ex.SocketErrorCode != SocketError.Interrupted)
                {
                    Close();
                    throw new NetworkException("Error while listening", ex);
                }
            }
            catch (Exception ex)
            {
                Close();
                throw new NetworkException("Error while listening", ex);
            }
        }

        /// <summary>
        /// Listen on the specified address and a random port for new connections. An incoming connection will
        /// create a new thread to handle the new connection. This method is not blocking and will return the port used to listen for new connections.
        /// </summary>
        /// <param name="listenAddress">The IP address to listen on new connections</param>
        /// <param name="backlog">The backlog, which indicates the number of parallelly handled connections</param>
        /// <returns>The port that is used to listen on new connections</returns>
        /// <exception cref="ArgumentNullException">listenAddress is null</exception>
        /// <exception cref="ArgumentOutOfRangeException">backlog must be greater than zero</exception>
        /// <exception cref="NetworkException">Error while listening</exception>
        public int ListenAsync(IPAddress listenAddress, uint backlog)
        {
            if (listenAddress == null)
            {
                throw new ArgumentNullException("listenAddress can not be null");
            }
            if (backlog == 0)
            {
                throw new ArgumentOutOfRangeException("The backlog must be greater than 0");
            }

            try
            {
                this.socket = new Socket(listenAddress.AddressFamily, SocketType.Stream, ProtocolType.Tcp);
                this.socket.Bind(new IPEndPoint(listenAddress, 0));
                this.socket.Listen((int)backlog);
                this.LocalAddress = ((IPEndPoint)this.socket.LocalEndPoint).Address.ToString();
                this.LocalPort = ((IPEndPoint)this.socket.LocalEndPoint).Port;
                this.closed = false;
                Thread connThread = new Thread(new ThreadStart(ListenConnectionWorker));
                connThread.IsBackground = true;
                connThread.Start();
                return this.LocalPort;
            }
            catch (SocketException ex)
            {
                throw new NetworkException("Error while start listening", ex);
            }
            catch (Exception ex)
            {
                throw new NetworkException("Error while start listening", ex);
            }
        }

        /// <summary>
        /// Waits for new connection.
        /// </summary>
        /// <param name="o"></param>
        private void ListenConnectionWorker()
        {
            try
            {
                logger.Debug(String.Format("Start listening on {0}:{1} for new connections.", this.LocalAddress, this.LocalPort));
                while (true)
                {
                    Socket newSocket = this.socket.Accept();

                    // Initialize and configure new connection
                    Connection conn = new Connection(newSocket, this.ReadTimeout, this.WriteTimeout);

                    // Inform the above layer that a new connection has been established.
                    if (this.NewConnectionEstablished != null)
                    {
                        this.NewConnectionEstablished.Invoke(this, new ConnectionEventArgs(conn));
                    }
                    Task.Factory.StartNew(ListenMessageWorker, conn);
                }
            }
            catch (SocketException ex)
            {
                if (ex.SocketErrorCode != SocketError.Interrupted)
                {
                    Close();
                    throw new NetworkException("Error while listening", ex);
                }
                logger.Debug("Connection closed: " + ToString());
            }
            catch (Exception ex)
            {
                Close();
                throw new NetworkException("Error while listening", ex);
            }
        }

        /// <summary>
        /// Waits for new messages.
        /// </summary>
        /// <param name="o"></param>
        private void ListenMessageWorker(object o)
        {
            Connection clientConnection = (Connection)o;
            clientConnection.ReceiveMessages();
        }

        #endregion


        /// <summary>
        /// Sends a message to the remote host.
        /// </summary>
        /// <param name="message">The message to send</param>
        /// <exception cref="System.ArgumentNullException">message is null</exception>
        /// <exception cref="NetworkException">Not connected</exception>
        public void SendMessage(IMessage message)
        {
            if (message == null)
            {
                throw new ArgumentNullException("Message can not be null!");
            }
            if (!this.Connected)
            {
                throw new NetworkException("Could not send message, because no connection established.");
            }
            if (this.binWriter == null)
            {
                // Do not send messages on listening sockets.
                throw new NetworkException("This connection does not support sending messages.");
            }

            try
            {
                lock (this.lockObj)
                {
                    this.binWriter.Write(message.Header.GetHeader());
                    this.binWriter.Write("\n");
                    if (message.Header.PayLoadLength == message.Payload.Length && message.Header.PayLoadLength > 0)
                    {
                        this.binWriter.Write(message.Payload);
                    }
                }
            }
            catch (IOException ex)
            {
                if (ex.InnerException != null && ex.InnerException.GetType().Equals(typeof(SocketException)))
                {
                    SocketException socketEx = (SocketException)ex.InnerException;
                    if (socketEx.SocketErrorCode == SocketError.Interrupted)
                    {
                        // Do nothing, as the above layer requested to close.
                        logger.Debug("(Send)Connection interrupted: " + ToString());
                        return;
                    }
                    else 
                    {
                        Close();
                        if (socketEx.SocketErrorCode == SocketError.TimedOut && this.ConnectionTimedOut != null)
                        {
                            // Timeout
                            this.ConnectionTimedOut.Invoke(this, new EventArgs());
                        }
                        else
                        {
                            throw new NetworkException("Error while sending messsage!", ex);
                        }
                    }
                }
                else
                {
                    Close();
                    throw new NetworkException("Error while sending messsage!", ex);
                }
            }
            catch (ObjectDisposedException ex)
            {
                logger.Debug("(Send) Connection object disposed: " + ToString());
                throw new NetworkException("Connection has already been closed", ex);
            }
        }

        /// <summary>
        /// This method is responsible for receiving new messages. Its blocking and will trigger events.
        /// Closing the connection will stop it.
        /// </summary>
        private void ReceiveMessages()
        {
            try
            {
                while (true)
                {
                    // Read the header
                    var buffer = "";
                    var sb = new StringBuilder();
                    while (buffer != "\n")
                    {
                        buffer = this.binReader.ReadString();
                        sb.AppendLine(buffer);
                    }
                    var header = new Header();
                    header.Parse(sb.ToString());

                    var payLoadBuffer = new byte[header.PayLoadLength];
                    if (header.PayLoadLength > 0)
                    {
                        // Read the payload
                        var totalbytes = 0;
                        do
                        {
                            var readbytes = this.binReader.Read(payLoadBuffer, totalbytes, payLoadBuffer.Length - totalbytes);
                            totalbytes += readbytes;
                        } while (totalbytes < payLoadBuffer.Length);
                    }
                    var message = new Message(header, payLoadBuffer);
                    if (this.MessageReceived != null)
                    {
                        this.MessageReceived.Invoke(this, new MessageReceivedEventArgs(message));
                    }

                }
            }
            catch (ObjectDisposedException)
            {
                // Socket closed locally
            }
            catch (EndOfStreamException)
            {
                // Socket was closed by remote host
            }
            catch (IOException ex)
            {
                if (ex.InnerException != null && ex.InnerException.GetType().Equals(typeof(SocketException)))
                {
                    SocketException socketEx = (SocketException)ex.InnerException;
                    if (socketEx.SocketErrorCode == SocketError.Interrupted)
                    {
                        // Socket was closed
                        return;
                    }
                    else if (socketEx.SocketErrorCode == SocketError.TimedOut)
                    {
                        // Timeout
                        if (this.ConnectionTimedOut != null)
                        {
                            this.ConnectionTimedOut.Invoke(this, new EventArgs());
                        }
                    }
                    else
                    {
                        // Insert your favorite SocketException here ;)
                        if (this.ConnectionClosed != null)
                        {
                            this.ConnectionClosed.Invoke(this, new EventArgs());
                        }
                    }
                }
                else
                {
                    // Insert your favorite IOException here ;)
                    if (this.ConnectionClosed != null)
                    {
                        this.ConnectionClosed.Invoke(this, new EventArgs());
                    }
                }
            }
            catch (Exception ex)
            {
                // Something else has happened on the network ... close connection
                if (this.ConnectionClosed != null)
                {
                    this.ConnectionClosed.Invoke(this, new EventArgs());
                }
                logger.Debug("(Receive) Connection Exception: " + ToString(), ex);
            }
            Close();
        }

        public override string ToString()
        {
            string s = (this.LocalAddress != null) ? this.LocalAddress + ":" + this.LocalPort : "-:-";
            s += " <-----> ";
            s += (this.RemoteAddress != null) ? this.RemoteAddress + ":" + this.RemotePort : "-:-";
            return s;
        }

        #region Properties and private member

        /// <summary>
        /// If no message will be received for the specified time in seconds, the connection will be closed 
        /// and the TimedOut event fires.
        /// </summary>
        public int ReadTimeout
        {
            get { return this.readTimeout; }
            set
            {
                this.readTimeout = value;
                if (this.binReader != null && this.binReader.BaseStream != null && this.binReader.BaseStream.CanRead)
                {
                    this.binReader.BaseStream.ReadTimeout = value * 1000;
                }
            }
        }

        /// <summary>
        /// If a message can not be written in the specified time in seconds, the connection will be closed 
        /// and the TimedOut event fires.
        /// </summary>
        public int WriteTimeout
        {
            get { return this.writeTimeout; }
            set
            {
                this.writeTimeout = value;
                if (this.binWriter != null && this.binWriter.BaseStream != null && this.binWriter.BaseStream.CanWrite)
                {
                    this.binWriter.BaseStream.WriteTimeout = value * 1000;
                }
            }
        }

        /// <summary>
        /// The remote IP address of this connection. Will persist even if the connection is closed, timedout, etc.
        /// If no connection is established, will be set to null.
        /// </summary>
        public string RemoteAddress { get; private set; }

        /// <summary>
        /// The remote port of this connection. Will persist even if the connection is closed, timedout, etc.
        /// If no connection is established, will be set to 0.
        /// </summary>
        public int RemotePort { get; private set; }

        /// <summary>
        /// The local IP address of this connection. Will persist even if the connection is closed, timedout, etc.
        /// If no connection is established, will be set to null.
        /// </summary>
        public string LocalAddress { get; private set; }

        /// <summary>
        /// The local port of this connection. Will persist even if the connection is closed, timedout, etc.
        /// If no connection is established, will be set to 0.
        /// </summary>
        public int LocalPort { get; private set; }

        /// <summary>
        /// Issues the state of this connection.
        /// </summary>
        public bool Connected
        {
            get { return (this.socket != null && this.socket.Connected); }
        }

        private Socket socket;

        private BinaryWriter binWriter;

        private BinaryReader binReader;

        private bool closed;

        private int readTimeout;

        private int writeTimeout;

        private object lockObj = new object();

        #endregion


        #region Events

        /// <summary>
        /// The connection has timed out.
        /// </summary>
        public event EventHandler<EventArgs> ConnectionTimedOut;

        /// <summary>
        /// The connection has been closed.
        /// </summary>
        public event EventHandler<EventArgs> ConnectionClosed;

        /// <summary>
        /// A new client has connected. This only fires if this is a listening connection.
        /// </summary>
        public event EventHandler<ConnectionEventArgs> NewConnectionEstablished;

        /// <summary>
        /// A message has been received.
        /// </summary>
        public event EventHandler<MessageReceivedEventArgs> MessageReceived;

        #endregion

    }
}
